@adminforth/markdown 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.log +2 -2
- package/custom/MarkdownEditor.vue +134 -14
- package/dist/custom/MarkdownEditor.vue +134 -14
- package/dist/index.js +81 -10
- package/index.ts +90 -10
- package/package.json +1 -1
package/build.log
CHANGED
|
@@ -10,5 +10,5 @@ custom/package-lock.json
|
|
|
10
10
|
custom/package.json
|
|
11
11
|
custom/tsconfig.json
|
|
12
12
|
|
|
13
|
-
sent
|
|
14
|
-
total size is
|
|
13
|
+
sent 29,778 bytes received 115 bytes 59,786.00 bytes/sec
|
|
14
|
+
total size is 29,346 speedup is 0.98
|
|
@@ -240,6 +240,8 @@ let removePasteListener: (() => void) | null = null;
|
|
|
240
240
|
let removePasteListenerSecondary: (() => void) | null = null;
|
|
241
241
|
let removeGlobalPasteListener: (() => void) | null = null;
|
|
242
242
|
let removeGlobalKeydownListener: (() => void) | null = null;
|
|
243
|
+
let removeDragOverListener: (() => void) | null = null;
|
|
244
|
+
let removeDropListener: (() => void) | null = null;
|
|
243
245
|
|
|
244
246
|
type MarkdownImageRef = {
|
|
245
247
|
lineNumber: number;
|
|
@@ -387,6 +389,62 @@ function fileFromClipboardImage(blob: Blob): File {
|
|
|
387
389
|
return new File([blob], filename, { type });
|
|
388
390
|
}
|
|
389
391
|
|
|
392
|
+
function escapeMarkdownLinkText(text: string): string {
|
|
393
|
+
return text.replace(/[\[\]\\]/g, '\\$&');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function showAdminforthError(message: string) {
|
|
397
|
+
const api = (window as any).adminforth;
|
|
398
|
+
if (api && typeof api.alert === 'function') {
|
|
399
|
+
api.alert({
|
|
400
|
+
message,
|
|
401
|
+
variant: 'danger',
|
|
402
|
+
timeout: 30,
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
console.error('[adminforth-markdown]', message);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function extractErrorMessage(error: any): string {
|
|
410
|
+
if (!error) return 'Upload failed';
|
|
411
|
+
if (typeof error === 'string') return error;
|
|
412
|
+
if (typeof error?.error === 'string') return error.error;
|
|
413
|
+
if (typeof error?.message === 'string') return error.message;
|
|
414
|
+
try {
|
|
415
|
+
return JSON.stringify(error);
|
|
416
|
+
} catch {
|
|
417
|
+
return 'Upload failed';
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function markdownForUploadedFile(file: File, url: string): string {
|
|
422
|
+
if (file.type?.startsWith('image/')) {
|
|
423
|
+
return ``;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (file.type?.startsWith('video/')) {
|
|
427
|
+
const mediaType = file.type || 'video/mp4';
|
|
428
|
+
return `<video width="400">\n<!-- For gif-like videos use: <video width="400" autoplay loop muted playsinline> -->\n <source src="${url}" type="${mediaType}">\n</video>`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// alert that file cant be uploaded
|
|
432
|
+
showAdminforthError(`Uploaded file "${file.name}" is not an image or video and cannot be embedded. It has been uploaded and can be accessed at: ${url}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function uploadFileAndGetMarkdownTag(file: File): Promise<string | undefined> {
|
|
436
|
+
try {
|
|
437
|
+
const url = await uploadFileToS3(file);
|
|
438
|
+
if (!url) return;
|
|
439
|
+
return markdownForUploadedFile(file, url);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
const message = extractErrorMessage(error);
|
|
442
|
+
showAdminforthError(message);
|
|
443
|
+
console.error('[adminforth-markdown] upload failed', error);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
390
448
|
onMounted(async () => {
|
|
391
449
|
if (!editorContainer.value) return;
|
|
392
450
|
try {
|
|
@@ -433,6 +491,59 @@ onMounted(async () => {
|
|
|
433
491
|
const noopPaste = () => {};
|
|
434
492
|
domNode.addEventListener('paste', noopPaste, true);
|
|
435
493
|
removePasteListener = () => domNode.removeEventListener('paste', noopPaste, true);
|
|
494
|
+
|
|
495
|
+
const onDragOver = (e: DragEvent) => {
|
|
496
|
+
if (!e.dataTransfer) return;
|
|
497
|
+
if (!Array.from(e.dataTransfer.types).includes('Files')) return;
|
|
498
|
+
e.preventDefault();
|
|
499
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const onDrop = async (e: DragEvent) => {
|
|
503
|
+
const dt = e.dataTransfer;
|
|
504
|
+
if (!dt) return;
|
|
505
|
+
if (!dt.files || !dt.files.length) return;
|
|
506
|
+
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
|
|
510
|
+
if (!editor) return;
|
|
511
|
+
editor.focus();
|
|
512
|
+
|
|
513
|
+
const target = editor.getTargetAtClientPoint(e.clientX, e.clientY);
|
|
514
|
+
const dropPosition = target?.position || target?.range?.getStartPosition?.();
|
|
515
|
+
if (dropPosition) {
|
|
516
|
+
editor.setPosition(dropPosition);
|
|
517
|
+
editor.setSelection(new monaco.Selection(
|
|
518
|
+
dropPosition.lineNumber,
|
|
519
|
+
dropPosition.column,
|
|
520
|
+
dropPosition.lineNumber,
|
|
521
|
+
dropPosition.column,
|
|
522
|
+
));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (!props.meta?.uploadPluginInstanceId) {
|
|
526
|
+
const msg = 'uploadPluginInstanceId is missing; cannot upload dropped file.';
|
|
527
|
+
showAdminforthError(msg);
|
|
528
|
+
console.error('[adminforth-markdown]', msg);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const markdownTags: string[] = [];
|
|
533
|
+
for (const file of Array.from(dt.files)) {
|
|
534
|
+
const tag = await uploadFileAndGetMarkdownTag(file);
|
|
535
|
+
if (tag) markdownTags.push(tag);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (markdownTags.length) {
|
|
539
|
+
insertAtCursor(`${markdownTags.join('\n\n')}\n`);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
domNode.addEventListener('dragover', onDragOver, true);
|
|
544
|
+
domNode.addEventListener('drop', onDrop, true);
|
|
545
|
+
removeDragOverListener = () => domNode.removeEventListener('dragover', onDragOver, true);
|
|
546
|
+
removeDropListener = () => domNode.removeEventListener('drop', onDrop, true);
|
|
436
547
|
}
|
|
437
548
|
if (editorContainer.value) {
|
|
438
549
|
const noopPaste = () => {};
|
|
@@ -496,15 +607,9 @@ onMounted(async () => {
|
|
|
496
607
|
const markdownTags: string[] = [];
|
|
497
608
|
for (const blob of imageBlobs) {
|
|
498
609
|
const file = blob instanceof File ? blob : fileFromClipboardImage(blob);
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (typeof url === 'string' && url.length) {
|
|
503
|
-
markdownTags.push(``);
|
|
504
|
-
}
|
|
505
|
-
} catch (err) {
|
|
506
|
-
console.error('[adminforth-markdown] upload failed', err);
|
|
507
|
-
}
|
|
610
|
+
const tag = await uploadFileAndGetMarkdownTag(file);
|
|
611
|
+
if (!tag) continue;
|
|
612
|
+
markdownTags.push(tag);
|
|
508
613
|
}
|
|
509
614
|
|
|
510
615
|
if (markdownTags.length) {
|
|
@@ -570,7 +675,7 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
|
|
|
570
675
|
const originalFilename = file.name.split('.').slice(0, -1).join('.');
|
|
571
676
|
const originalExtension = file.name.split('.').pop();
|
|
572
677
|
|
|
573
|
-
const { uploadUrl, tagline, previewUrl,
|
|
678
|
+
const { uploadUrl, tagline, previewUrl, error } = await callAdminForthApi({
|
|
574
679
|
path: `/plugin/${props.meta.uploadPluginInstanceId}/get_file_upload_url`,
|
|
575
680
|
method: 'POST',
|
|
576
681
|
body: {
|
|
@@ -582,8 +687,13 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
|
|
|
582
687
|
});
|
|
583
688
|
|
|
584
689
|
if (error) {
|
|
585
|
-
|
|
586
|
-
|
|
690
|
+
const message = extractErrorMessage(error);
|
|
691
|
+
if (/too\s*large|max\s*file\s*size|size\s*limit|limit\s*reached|exceed/i.test(message)) {
|
|
692
|
+
showAdminforthError(message);
|
|
693
|
+
} else {
|
|
694
|
+
showAdminforthError(message);
|
|
695
|
+
}
|
|
696
|
+
throw new Error(message);
|
|
587
697
|
}
|
|
588
698
|
|
|
589
699
|
const xhr = new XMLHttpRequest();
|
|
@@ -597,12 +707,16 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
|
|
|
597
707
|
if (xhr.status === 200) {
|
|
598
708
|
resolve(previewUrl as string);
|
|
599
709
|
} else {
|
|
600
|
-
|
|
710
|
+
const message = `Error uploading to S3 (status ${xhr.status})`;
|
|
711
|
+
showAdminforthError(message);
|
|
712
|
+
reject(message);
|
|
601
713
|
}
|
|
602
714
|
};
|
|
603
715
|
|
|
604
716
|
xhr.onerror = () => {
|
|
605
|
-
|
|
717
|
+
const message = 'Error uploading to S3';
|
|
718
|
+
showAdminforthError(message);
|
|
719
|
+
reject(message);
|
|
606
720
|
};
|
|
607
721
|
});
|
|
608
722
|
}
|
|
@@ -627,6 +741,12 @@ onBeforeUnmount(() => {
|
|
|
627
741
|
removeGlobalKeydownListener?.();
|
|
628
742
|
removeGlobalKeydownListener = null;
|
|
629
743
|
|
|
744
|
+
removeDragOverListener?.();
|
|
745
|
+
removeDragOverListener = null;
|
|
746
|
+
|
|
747
|
+
removeDropListener?.();
|
|
748
|
+
removeDropListener = null;
|
|
749
|
+
|
|
630
750
|
for (const d of disposables) d.dispose();
|
|
631
751
|
disposables.length = 0;
|
|
632
752
|
|
|
@@ -240,6 +240,8 @@ let removePasteListener: (() => void) | null = null;
|
|
|
240
240
|
let removePasteListenerSecondary: (() => void) | null = null;
|
|
241
241
|
let removeGlobalPasteListener: (() => void) | null = null;
|
|
242
242
|
let removeGlobalKeydownListener: (() => void) | null = null;
|
|
243
|
+
let removeDragOverListener: (() => void) | null = null;
|
|
244
|
+
let removeDropListener: (() => void) | null = null;
|
|
243
245
|
|
|
244
246
|
type MarkdownImageRef = {
|
|
245
247
|
lineNumber: number;
|
|
@@ -387,6 +389,62 @@ function fileFromClipboardImage(blob: Blob): File {
|
|
|
387
389
|
return new File([blob], filename, { type });
|
|
388
390
|
}
|
|
389
391
|
|
|
392
|
+
function escapeMarkdownLinkText(text: string): string {
|
|
393
|
+
return text.replace(/[\[\]\\]/g, '\\$&');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function showAdminforthError(message: string) {
|
|
397
|
+
const api = (window as any).adminforth;
|
|
398
|
+
if (api && typeof api.alert === 'function') {
|
|
399
|
+
api.alert({
|
|
400
|
+
message,
|
|
401
|
+
variant: 'danger',
|
|
402
|
+
timeout: 30,
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
console.error('[adminforth-markdown]', message);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function extractErrorMessage(error: any): string {
|
|
410
|
+
if (!error) return 'Upload failed';
|
|
411
|
+
if (typeof error === 'string') return error;
|
|
412
|
+
if (typeof error?.error === 'string') return error.error;
|
|
413
|
+
if (typeof error?.message === 'string') return error.message;
|
|
414
|
+
try {
|
|
415
|
+
return JSON.stringify(error);
|
|
416
|
+
} catch {
|
|
417
|
+
return 'Upload failed';
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function markdownForUploadedFile(file: File, url: string): string {
|
|
422
|
+
if (file.type?.startsWith('image/')) {
|
|
423
|
+
return ``;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (file.type?.startsWith('video/')) {
|
|
427
|
+
const mediaType = file.type || 'video/mp4';
|
|
428
|
+
return `<video width="400">\n<!-- For gif-like videos use: <video width="400" autoplay loop muted playsinline> -->\n <source src="${url}" type="${mediaType}">\n</video>`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// alert that file cant be uploaded
|
|
432
|
+
showAdminforthError(`Uploaded file "${file.name}" is not an image or video and cannot be embedded. It has been uploaded and can be accessed at: ${url}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function uploadFileAndGetMarkdownTag(file: File): Promise<string | undefined> {
|
|
436
|
+
try {
|
|
437
|
+
const url = await uploadFileToS3(file);
|
|
438
|
+
if (!url) return;
|
|
439
|
+
return markdownForUploadedFile(file, url);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
const message = extractErrorMessage(error);
|
|
442
|
+
showAdminforthError(message);
|
|
443
|
+
console.error('[adminforth-markdown] upload failed', error);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
390
448
|
onMounted(async () => {
|
|
391
449
|
if (!editorContainer.value) return;
|
|
392
450
|
try {
|
|
@@ -433,6 +491,59 @@ onMounted(async () => {
|
|
|
433
491
|
const noopPaste = () => {};
|
|
434
492
|
domNode.addEventListener('paste', noopPaste, true);
|
|
435
493
|
removePasteListener = () => domNode.removeEventListener('paste', noopPaste, true);
|
|
494
|
+
|
|
495
|
+
const onDragOver = (e: DragEvent) => {
|
|
496
|
+
if (!e.dataTransfer) return;
|
|
497
|
+
if (!Array.from(e.dataTransfer.types).includes('Files')) return;
|
|
498
|
+
e.preventDefault();
|
|
499
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const onDrop = async (e: DragEvent) => {
|
|
503
|
+
const dt = e.dataTransfer;
|
|
504
|
+
if (!dt) return;
|
|
505
|
+
if (!dt.files || !dt.files.length) return;
|
|
506
|
+
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
|
|
510
|
+
if (!editor) return;
|
|
511
|
+
editor.focus();
|
|
512
|
+
|
|
513
|
+
const target = editor.getTargetAtClientPoint(e.clientX, e.clientY);
|
|
514
|
+
const dropPosition = target?.position || target?.range?.getStartPosition?.();
|
|
515
|
+
if (dropPosition) {
|
|
516
|
+
editor.setPosition(dropPosition);
|
|
517
|
+
editor.setSelection(new monaco.Selection(
|
|
518
|
+
dropPosition.lineNumber,
|
|
519
|
+
dropPosition.column,
|
|
520
|
+
dropPosition.lineNumber,
|
|
521
|
+
dropPosition.column,
|
|
522
|
+
));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (!props.meta?.uploadPluginInstanceId) {
|
|
526
|
+
const msg = 'uploadPluginInstanceId is missing; cannot upload dropped file.';
|
|
527
|
+
showAdminforthError(msg);
|
|
528
|
+
console.error('[adminforth-markdown]', msg);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const markdownTags: string[] = [];
|
|
533
|
+
for (const file of Array.from(dt.files)) {
|
|
534
|
+
const tag = await uploadFileAndGetMarkdownTag(file);
|
|
535
|
+
if (tag) markdownTags.push(tag);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (markdownTags.length) {
|
|
539
|
+
insertAtCursor(`${markdownTags.join('\n\n')}\n`);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
domNode.addEventListener('dragover', onDragOver, true);
|
|
544
|
+
domNode.addEventListener('drop', onDrop, true);
|
|
545
|
+
removeDragOverListener = () => domNode.removeEventListener('dragover', onDragOver, true);
|
|
546
|
+
removeDropListener = () => domNode.removeEventListener('drop', onDrop, true);
|
|
436
547
|
}
|
|
437
548
|
if (editorContainer.value) {
|
|
438
549
|
const noopPaste = () => {};
|
|
@@ -496,15 +607,9 @@ onMounted(async () => {
|
|
|
496
607
|
const markdownTags: string[] = [];
|
|
497
608
|
for (const blob of imageBlobs) {
|
|
498
609
|
const file = blob instanceof File ? blob : fileFromClipboardImage(blob);
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if (typeof url === 'string' && url.length) {
|
|
503
|
-
markdownTags.push(``);
|
|
504
|
-
}
|
|
505
|
-
} catch (err) {
|
|
506
|
-
console.error('[adminforth-markdown] upload failed', err);
|
|
507
|
-
}
|
|
610
|
+
const tag = await uploadFileAndGetMarkdownTag(file);
|
|
611
|
+
if (!tag) continue;
|
|
612
|
+
markdownTags.push(tag);
|
|
508
613
|
}
|
|
509
614
|
|
|
510
615
|
if (markdownTags.length) {
|
|
@@ -570,7 +675,7 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
|
|
|
570
675
|
const originalFilename = file.name.split('.').slice(0, -1).join('.');
|
|
571
676
|
const originalExtension = file.name.split('.').pop();
|
|
572
677
|
|
|
573
|
-
const { uploadUrl, tagline, previewUrl,
|
|
678
|
+
const { uploadUrl, tagline, previewUrl, error } = await callAdminForthApi({
|
|
574
679
|
path: `/plugin/${props.meta.uploadPluginInstanceId}/get_file_upload_url`,
|
|
575
680
|
method: 'POST',
|
|
576
681
|
body: {
|
|
@@ -582,8 +687,13 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
|
|
|
582
687
|
});
|
|
583
688
|
|
|
584
689
|
if (error) {
|
|
585
|
-
|
|
586
|
-
|
|
690
|
+
const message = extractErrorMessage(error);
|
|
691
|
+
if (/too\s*large|max\s*file\s*size|size\s*limit|limit\s*reached|exceed/i.test(message)) {
|
|
692
|
+
showAdminforthError(message);
|
|
693
|
+
} else {
|
|
694
|
+
showAdminforthError(message);
|
|
695
|
+
}
|
|
696
|
+
throw new Error(message);
|
|
587
697
|
}
|
|
588
698
|
|
|
589
699
|
const xhr = new XMLHttpRequest();
|
|
@@ -597,12 +707,16 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
|
|
|
597
707
|
if (xhr.status === 200) {
|
|
598
708
|
resolve(previewUrl as string);
|
|
599
709
|
} else {
|
|
600
|
-
|
|
710
|
+
const message = `Error uploading to S3 (status ${xhr.status})`;
|
|
711
|
+
showAdminforthError(message);
|
|
712
|
+
reject(message);
|
|
601
713
|
}
|
|
602
714
|
};
|
|
603
715
|
|
|
604
716
|
xhr.onerror = () => {
|
|
605
|
-
|
|
717
|
+
const message = 'Error uploading to S3';
|
|
718
|
+
showAdminforthError(message);
|
|
719
|
+
reject(message);
|
|
606
720
|
};
|
|
607
721
|
});
|
|
608
722
|
}
|
|
@@ -627,6 +741,12 @@ onBeforeUnmount(() => {
|
|
|
627
741
|
removeGlobalKeydownListener?.();
|
|
628
742
|
removeGlobalKeydownListener = null;
|
|
629
743
|
|
|
744
|
+
removeDragOverListener?.();
|
|
745
|
+
removeDragOverListener = null;
|
|
746
|
+
|
|
747
|
+
removeDropListener?.();
|
|
748
|
+
removeDropListener = null;
|
|
749
|
+
|
|
630
750
|
for (const d of disposables) d.dispose();
|
|
631
751
|
disposables.length = 0;
|
|
632
752
|
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,14 @@ export default class MarkdownPlugin extends AdminForthPlugin {
|
|
|
17
17
|
instanceUniqueRepresentation(pluginOptions) {
|
|
18
18
|
return pluginOptions.fieldName;
|
|
19
19
|
}
|
|
20
|
+
// Placeholder for future Upload Plugin API integration.
|
|
21
|
+
// For now, treat all extracted URLs as plugin-owned public URLs.
|
|
22
|
+
isPluginPublicUrl(_url) {
|
|
23
|
+
// todo: here we need to check that host name is same as upload plugin, probably create upload plugin endpoint
|
|
24
|
+
// should handle cases that user might define custom preview url
|
|
25
|
+
// and that local storage has no host name, here, the fact of luck of hostname might be used as
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
20
28
|
validateConfigAfterDiscover(adminforth, resourceConfig) {
|
|
21
29
|
this.adminforth = adminforth;
|
|
22
30
|
const column = resourceConfig.columns.find(c => c.name === this.options.fieldName);
|
|
@@ -111,31 +119,94 @@ export default class MarkdownPlugin extends AdminForthPlugin {
|
|
|
111
119
|
};
|
|
112
120
|
const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
|
|
113
121
|
if (this.options.attachments) {
|
|
114
|
-
const
|
|
122
|
+
const stripQueryAndHash = (value) => value.split('#')[0].split('?')[0];
|
|
123
|
+
const extractKeyFromUrl = (url) => {
|
|
124
|
+
// Supports absolute https/http URLs and protocol-relative URLs.
|
|
125
|
+
// Returns the object key as a path without leading slashes.
|
|
126
|
+
try {
|
|
127
|
+
const normalized = url.startsWith('//') ? `https:${url}` : url;
|
|
128
|
+
const u = new URL(normalized);
|
|
129
|
+
return u.pathname.replace(/^\/+/, '');
|
|
130
|
+
}
|
|
131
|
+
catch (_a) {
|
|
132
|
+
// Fallback: strip scheme/host if it looks like a URL, otherwise treat as a path.
|
|
133
|
+
return stripQueryAndHash(url).replace(/^https?:\/\/[^\/]+\/+/, '').replace(/^\/+/, '');
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const shouldTrackUrl = (url) => {
|
|
137
|
+
try {
|
|
138
|
+
return this.isPluginPublicUrl(url);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.error('Error checking URL ownership', url, err);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const getKeyFromTrackedUrl = (rawUrl) => {
|
|
146
|
+
const srcTrimmed = rawUrl.trim().replace(/^<|>$/g, '');
|
|
147
|
+
if (!srcTrimmed || srcTrimmed.startsWith('data:') || srcTrimmed.startsWith('javascript:')) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (!shouldTrackUrl(srcTrimmed)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const srcNoQuery = stripQueryAndHash(srcTrimmed);
|
|
154
|
+
const key = extractKeyFromUrl(srcNoQuery);
|
|
155
|
+
if (!key) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return key;
|
|
159
|
+
};
|
|
160
|
+
const upsertMeta = (byKey, key, next) => {
|
|
161
|
+
var _a, _b;
|
|
162
|
+
const existing = byKey.get(key);
|
|
163
|
+
if (!existing) {
|
|
164
|
+
byKey.set(key, {
|
|
165
|
+
key,
|
|
166
|
+
alt: (_a = next.alt) !== null && _a !== void 0 ? _a : null,
|
|
167
|
+
title: (_b = next.title) !== null && _b !== void 0 ? _b : null,
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if ((existing.alt === null || existing.alt === '') && next.alt !== undefined && next.alt !== null) {
|
|
172
|
+
existing.alt = next.alt;
|
|
173
|
+
}
|
|
174
|
+
if ((existing.title === null || existing.title === '') && next.title !== undefined && next.title !== null) {
|
|
175
|
+
existing.title = next.title;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
115
178
|
function getAttachmentMetas(markdown) {
|
|
116
|
-
var _a, _b, _c;
|
|
179
|
+
var _a, _b, _c, _d, _e, _f;
|
|
117
180
|
if (!markdown) {
|
|
118
181
|
return [];
|
|
119
182
|
}
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
183
|
+
// Markdown image syntax:  or  or 
|
|
184
|
+
const imageRegex = /!\[([^\]]*)\]\(\s*([^\s)]+)\s*(?:\s+(?:\"([^\"]*)\"|'([^']*)'))?\s*\)/g;
|
|
185
|
+
// HTML embedded media links.
|
|
186
|
+
const htmlSrcRegex = /<(?:source|video)\b[^>]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s"'=<>`]+))[^>]*>/gi;
|
|
123
187
|
const byKey = new Map();
|
|
124
188
|
for (const match of markdown.matchAll(imageRegex)) {
|
|
125
189
|
const altRaw = (_a = match[1]) !== null && _a !== void 0 ? _a : '';
|
|
126
190
|
const srcRaw = match[2];
|
|
127
191
|
const titleRaw = (_c = ((_b = match[3]) !== null && _b !== void 0 ? _b : match[4])) !== null && _c !== void 0 ? _c : null;
|
|
128
|
-
const
|
|
129
|
-
if (!
|
|
192
|
+
const key = getKeyFromTrackedUrl(srcRaw);
|
|
193
|
+
if (!key) {
|
|
130
194
|
continue;
|
|
131
195
|
}
|
|
132
|
-
|
|
133
|
-
byKey.set(key, {
|
|
134
|
-
key,
|
|
196
|
+
upsertMeta(byKey, key, {
|
|
135
197
|
alt: altRaw,
|
|
136
198
|
title: titleRaw,
|
|
137
199
|
});
|
|
138
200
|
}
|
|
201
|
+
let srcMatch;
|
|
202
|
+
while ((srcMatch = htmlSrcRegex.exec(markdown)) !== null) {
|
|
203
|
+
const srcRaw = (_f = (_e = (_d = srcMatch[1]) !== null && _d !== void 0 ? _d : srcMatch[2]) !== null && _e !== void 0 ? _e : srcMatch[3]) !== null && _f !== void 0 ? _f : '';
|
|
204
|
+
const key = getKeyFromTrackedUrl(srcRaw);
|
|
205
|
+
if (!key) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
upsertMeta(byKey, key, {});
|
|
209
|
+
}
|
|
139
210
|
return [...byKey.values()];
|
|
140
211
|
}
|
|
141
212
|
const createAttachmentRecords = (adminforth, options, recordId, metas, adminUser) => __awaiter(this, void 0, void 0, function* () {
|
package/index.ts
CHANGED
|
@@ -17,6 +17,15 @@ export default class MarkdownPlugin extends AdminForthPlugin {
|
|
|
17
17
|
return pluginOptions.fieldName;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// Placeholder for future Upload Plugin API integration.
|
|
21
|
+
// For now, treat all extracted URLs as plugin-owned public URLs.
|
|
22
|
+
isPluginPublicUrl(_url: string): boolean {
|
|
23
|
+
// todo: here we need to check that host name is same as upload plugin, probably create upload plugin endpoint
|
|
24
|
+
// should handle cases that user might define custom preview url
|
|
25
|
+
// and that local storage has no host name, here, the fact of luck of hostname might be used as
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
21
30
|
this.adminforth = adminforth;
|
|
22
31
|
const column = resourceConfig.columns.find(c => c.name === this.options.fieldName);
|
|
@@ -122,16 +131,79 @@ export default class MarkdownPlugin extends AdminForthPlugin {
|
|
|
122
131
|
|
|
123
132
|
type AttachmentMeta = { key: string; alt: string | null; title: string | null };
|
|
124
133
|
|
|
125
|
-
const
|
|
134
|
+
const stripQueryAndHash = (value: string) => value.split('#')[0].split('?')[0];
|
|
135
|
+
|
|
136
|
+
const extractKeyFromUrl = (url: string) => {
|
|
137
|
+
// Supports absolute https/http URLs and protocol-relative URLs.
|
|
138
|
+
// Returns the object key as a path without leading slashes.
|
|
139
|
+
try {
|
|
140
|
+
const normalized = url.startsWith('//') ? `https:${url}` : url;
|
|
141
|
+
const u = new URL(normalized);
|
|
142
|
+
return u.pathname.replace(/^\/+/, '');
|
|
143
|
+
} catch {
|
|
144
|
+
// Fallback: strip scheme/host if it looks like a URL, otherwise treat as a path.
|
|
145
|
+
return stripQueryAndHash(url).replace(/^https?:\/\/[^\/]+\/+/, '').replace(/^\/+/, '');
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const shouldTrackUrl = (url: string) => {
|
|
150
|
+
try {
|
|
151
|
+
return this.isPluginPublicUrl(url);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error('Error checking URL ownership', url, err);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const getKeyFromTrackedUrl = (rawUrl: string): string | null => {
|
|
159
|
+
const srcTrimmed = rawUrl.trim().replace(/^<|>$/g, '');
|
|
160
|
+
if (!srcTrimmed || srcTrimmed.startsWith('data:') || srcTrimmed.startsWith('javascript:')) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (!shouldTrackUrl(srcTrimmed)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const srcNoQuery = stripQueryAndHash(srcTrimmed);
|
|
167
|
+
const key = extractKeyFromUrl(srcNoQuery);
|
|
168
|
+
if (!key) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return key;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const upsertMeta = (
|
|
175
|
+
byKey: Map<string, AttachmentMeta>,
|
|
176
|
+
key: string,
|
|
177
|
+
next: { alt?: string | null; title?: string | null }
|
|
178
|
+
) => {
|
|
179
|
+
const existing = byKey.get(key);
|
|
180
|
+
if (!existing) {
|
|
181
|
+
byKey.set(key, {
|
|
182
|
+
key,
|
|
183
|
+
alt: next.alt ?? null,
|
|
184
|
+
title: next.title ?? null,
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if ((existing.alt === null || existing.alt === '') && next.alt !== undefined && next.alt !== null) {
|
|
190
|
+
existing.alt = next.alt;
|
|
191
|
+
}
|
|
192
|
+
if ((existing.title === null || existing.title === '') && next.title !== undefined && next.title !== null) {
|
|
193
|
+
existing.title = next.title;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
126
196
|
|
|
127
197
|
function getAttachmentMetas(markdown: string): AttachmentMeta[] {
|
|
128
198
|
if (!markdown) {
|
|
129
199
|
return [];
|
|
130
200
|
}
|
|
131
201
|
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
202
|
+
// Markdown image syntax:  or  or 
|
|
203
|
+
const imageRegex = /!\[([^\]]*)\]\(\s*([^\s)]+)\s*(?:\s+(?:\"([^\"]*)\"|'([^']*)'))?\s*\)/g;
|
|
204
|
+
|
|
205
|
+
// HTML embedded media links.
|
|
206
|
+
const htmlSrcRegex = /<(?:source|video)\b[^>]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s"'=<>`]+))[^>]*>/gi;
|
|
135
207
|
|
|
136
208
|
const byKey = new Map<string, AttachmentMeta>();
|
|
137
209
|
for (const match of markdown.matchAll(imageRegex)) {
|
|
@@ -139,18 +211,26 @@ export default class MarkdownPlugin extends AdminForthPlugin {
|
|
|
139
211
|
const srcRaw = match[2];
|
|
140
212
|
const titleRaw = (match[3] ?? match[4]) ?? null;
|
|
141
213
|
|
|
142
|
-
const
|
|
143
|
-
if (!
|
|
214
|
+
const key = getKeyFromTrackedUrl(srcRaw);
|
|
215
|
+
if (!key) {
|
|
144
216
|
continue;
|
|
145
217
|
}
|
|
146
|
-
|
|
147
|
-
const key = extractKeyFromUrl(srcNoQuery);
|
|
148
|
-
byKey.set(key, {
|
|
149
|
-
key,
|
|
218
|
+
upsertMeta(byKey, key, {
|
|
150
219
|
alt: altRaw,
|
|
151
220
|
title: titleRaw,
|
|
152
221
|
});
|
|
153
222
|
}
|
|
223
|
+
|
|
224
|
+
let srcMatch: RegExpExecArray | null;
|
|
225
|
+
while ((srcMatch = htmlSrcRegex.exec(markdown)) !== null) {
|
|
226
|
+
const srcRaw = srcMatch[1] ?? srcMatch[2] ?? srcMatch[3] ?? '';
|
|
227
|
+
const key = getKeyFromTrackedUrl(srcRaw);
|
|
228
|
+
if (!key) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
upsertMeta(byKey, key, {});
|
|
232
|
+
}
|
|
233
|
+
|
|
154
234
|
return [...byKey.values()];
|
|
155
235
|
}
|
|
156
236
|
|